서론
지난 포스트 Python2, 헤어질 결심 (1) 글에서 ci-test 시간을 13분에서 5분으로 단축한 과정을 공유했습니다. 해당 개선 작업에서는 아래와 같은 불필요한 과정 제거에 집중했습니다.
- PowerShell 설치 제거 (56초 절약)
- Python 소스 빌드 → Docker 이미지로 전환
- apt upgrade 제거 및 필수 패키지만 설치
위의 개선으로 전체 배포 시간을 50% 이상 단축할 수 있었지만 5분씩 걸리는 시간도 여전히 아쉬웠고 무엇보다 캐시를 활용하여도 매번 패키지를 설치하는 비효율적인 작업이 있었습니다. 5분이라는 시간도 들쑥날쑥하게 변해 5분보다 오래 걸릴 때도 있었습니다.🥲
지난 작업이 불필요한 것을 제거하는데 초점을 뒀다면, 이번 개선에서는 필요한 것을 재사용하는 캐싱의 관점으로 ci-test 시간을 5분에서 2분 내외로 추가 단축한 과정을 공유합니다. 캐싱과 더불어 불필요한 패키지 설치 과정 제거 및 Disk I/O 대신 RAM 을 사용하는 방식으로 추가 개선했습니다.
- 패키지 캐싱 전략 개선 - pip packages 직접 캐싱
- APT 설치 최적화 - 조건부 실행과 설정 튜닝
- mysql 테스트 최적화 - MySQL tmpfs, 컴파일 옵션, 검증 단계 추가
1. pip 패키지 캐싱 전략의 근본적 개선
기존 방식의 문제점
기존 CI 과정은 pip의 다운로드 캐시만 저장하고 있었습니다.
이 방식은 패키지 파일(.whl
, .tar.gz
)은 캐싱하지만, 설치된 패키지 자체는 캐싱하지 않습니다. 그래서 requirements.txt
가 변경되지 않아도 매번 pip install을 실행해야 했죠.
- name: Setup pip cache uses: actions/cache@v3 with: path: ${{ github.workspace }}/.cache/pip key: ${{ runner.os }}-pip-v4-${{ hashFiles('honeyscreen/requirements.txt') }}
pip 다운로드 캐시 vs 설치된 패키지 캐싱
AS-IS: 다운로드 캐시만 있는 경우
- pip install 실행 과정 1. ✅ PyPI에서 패키지 다운로드 (.whl, .tar.gz) → 캐시 가능 2. 다운로드한 파일 압축 해제 → 매번 실행 3. site-packages에 설치 → 매번 실행 4. 의존성 검사 및 바이너리 컴파일 → 매번 실행
즉 원격 서버에서 패키지를 받아오는 시간은 절약되지만 압축 해제, 설치, 컴파일 작업은 여전히 매번 실행되고 있었습니다.
TO-BE: 설치된 패키지 자체를 캐싱
Docker Layer 캐싱 원리에 따라 "변경이 적은 레이어는 위쪽에"라는 원칙을 적용했습니다. pip 패키지는 requirements.txt가 바뀌지 않는 한 동일하기 때문에 설치된 site-packages 디렉토리 자체를 캐싱하기로 했습니다. 요리에 빗대어 보면 기존 방식은 재료(패키지 파일)만 캐싱, 개선된 방식은 완성된 요리(설치된 패키지)를 통째로 캐싱합니다.
- name: Setup pip packages cache uses: actions/cache@v3 id: pip-packages-cache with: path: /usr/local/lib/python2.7/site-packages key: ${{ runner.os }}-py27-packages-v2-${{ hashFiles('honeyscreen/requirements.txt') }}
왜 site-packages
를 캐싱할까요?
/usr/local/lib/python2.7/site-packages 는 pip 가 모든 패키지를 설치하는 최종 목적지 입니다.
이 디렉토리에는 압축 해제된 python 모듈과 컴파일 된 바이너리, 패키지 메타데이터 등의 정보가 모두 준비된 상태로 저장됩니다.
예를 들어 python 이 import django 를 실행할 수 있는 완성된 상태가 위 디렉토리에 준비되어 있는 것이죠.
즉 실행 가능 상태의 재료가 site-packages 에 저장되므로 이를 캐싱하여 2분 넘게 걸리던 불필요한 실행 작업을 건너 뛸 수 있습니다 (2min -> 0s)
- name: Install packages if: steps.pip-packages-cache.outputs.cache-hit != 'true' run: | echo "Installing dependencies (cache miss)" pip install -r honeyscreen/requirements.txt
- cache hit: 패키지 설치 완전히 스킵
- cache miss: 패키지 설치 진행
2. APT 패키지 설치 최적화
조건부 실행으로 불필요한 작업 제거
기존에는 설치 여부를 확인하지 않은 채 매번 시스템 패키지를 설치했다면, 시스템 패키지가 설치 여부를 체크 후 다음 스텝으로 넘어가도록 구조를 개선했습니다.
command -v
는 명령어가 존재하는지 확인하는 쉘 내장 명령어입니다. 모든 필수 패키지가 이미 설치되어 있다면 apt-get을 실행하지 않고 바로 종료합니다.
self-hosted runner 환경에서는 이전 빌드의 패키지가 남아있는 경우가 많아 이 체크만으로도 2-3분을 절약할 수 있었습니다.
- name: Install required packages run: | # 이미 설치된 패키지 확인 if command -v curl && command -v wget && command -v mysql && command -v gcc; then echo "✓ All required packages already installed" exit 0 fi # 설치가 필요한 경우에만 apt-get 실행 apt-get update -qq apt-get install -yqq curl wget default-mysql-client gcc python-dev
APT 성능 튜닝 - 최소한의 다운로드만
apt-get을 실행할 때도 여러 최적화 옵션을 추가했습니다.
export DEBIAN_FRONTEND=noninteractive apt-get update -qq \\ -o Acquire::Languages="none" \\ # 언어 파일 다운로드 스킵 -o APT::Install-Recommends="false" \\ # 추천 패키지 설치 안 함 -o APT::Install-Suggests="false" # 제안 패키지 설치 안 함 * Install only missing packages apt-get install -yqq --no-install-recommends \ -o Dir::Cache::archives="$GITHUB_WORKSPACE/.apt/cache/archives" \ -o Dir::State::lists="$GITHUB_WORKSPACE/.apt/state/lists" \ -o Dpkg::Options::="--force-confdef" \ -o Dpkg::Options::="--force-confold" \ curl wget default-mysql-client gcc python-dev libssl-dev zlib1g-dev
각 옵션의 의미:
- DEBIAN_FRONTEND=noninteractive
APT가 대화형 프롬프트를 표시하지 않도록 합니다. CI 환경에서는 사용자 입력을 받을 수 없으므로 해당 설정을 추가하여 disable 했습니다.
- qq (quiet mode)
진행 상황 메시지를 최소화합니다. 에러만 출력되므로 필요한 로그만 확인할 수 있습니다.
- o Acquire::Languages="none"
패키지 설명의 다국어 번역 파일을 다운로드하지 않습니다. CI 에서는 불필요하므로 수 MB의 다운로드를 절약할 수 있습니다.
- o APT::Install-Recommends="false"
APT는 기본적으로 "추천 패키지"도 함께 설치합니다. 예를 들어
gcc
를 설치하면g++
,make
등도 함께 설치되는데, 현재 허니스크린은gcc
만 필요하므로 이를 비활성화했습니다.
- o APT::Install-Suggests="false"
"제안 패키지"까지 설치를 방지합니다. Suggests 는 Recommends 보다 더 느슨한 관계로 완전히 선택적인 패키지들입니다.
불필요한 download-only 단계 제거
기존 코드는 패키지를 두 번에 걸쳐 설치했습니다.
이는 "캐시를 먼저 채우고 설치하자"는 의도였지만:
- 캐시가 있으면 어차피 빠르게 설치됨
- 캐시가 없으면 두 번 실행하는 것이 오히려 오버헤드
결국 한 번에 설치하는 것이 더 효율적이라 이에 맞게 개선했습니다.
- as-is 2 단계로 나누어 설치 apt-get install -y --download-only curl wget ... # 1단계: 다운로드만 apt-get install -y curl wget ... # 2단계: 실제 설치 - to-be 한 번에 설치 apt-get install -yqq --no-install-recommends curl wget ...
3. 추가 최적화
MySQL tmpfs 설정
테스트용 MySQL은 디스크 I/O가 병목이 될 수 있어서 tmpfs(메모리)를 사용하도록 설정했습니다.
tmpfs
는 temporary file system 의 약자로 디스크가 아닌 RAM 에 파일 시스템을 올리는 개념입니다.
일반적인 파일 시스템과 동일하게 사용할 수 있지만 모든 데이터가 메모리에 저장되어 Disk I/O 보다 빠른 속도를 낼 수 있습니다.
CI 테스트 환경에서는 데이터를 영구적으로 보관할 필요가 없을 뿐더러 ci 속도 향상을 위해서 빠른 read/write 가 가능한 tmpfs 를 사용하는 것이 적절하다고 판단했습니다.
services: mysql: options: >- --tmpfs /var/lib/mysql:rw,noexec,nosuid,size=256m # rw (read-write): 읽기/쓰기 모두 가능 # noexec: 이 파일시스템에서 실행 파일을 실행할 수 없음 → 악성 코드 실행 방지 # nosuid: setuid/setgid 비트를 무시 → 권한 상승 공격 방지 # size=256m: 최대 256MB까지 사용 (미지정 시 호스트 RAM의 50%가 기본값)
MySQL의 데이터 디렉토리(/var/lib/mysql
)를 tmpfs로 마운트하면:
- 디스크 I/O 대신 메모리 I/O 사용
- 테이블 생성, INSERT, SELECT 등 모든 DB 작업이 빨라짐
- 컨테이너 종료 시 자동 clean up
메모리가 부족할 경우 swap 영역(디스크)으로 스왑될 수 있습니다. CI 환경에서는 일반적으로 문제가 되지 않지만 완전한 메모리 전용 동작을 원한다면 충분한 메모리를 확보해야 합니다.
결론 및 추가 개선 과제
전체 개선 여정 : 13분 -> 5분 -> 2분
지난 7월 첫 번째 개선 작업에서는 불필요한 작업을 제거하여 CI 시간을 13분에서 5분으로 단축했고, 이번 두 번째 개선에서는 캐싱 전략을 근본적으로 바꿔 5분에서 2분 대로 추가 단축할 수 있었습니다.
이번 개선을 통해 무엇을 캐싱할지 에 따라 결과가 달라지는 것을 볼 수 있었습니다.
Docker Layer 캐싱 원칙
에 따라 변경이 적은 부분을 상위 layer 에서 처리하며 requirements.txt
가 변하지 않는
대부분의 PR 에서 패키지 설치를 완전히 생략할 수 있었습니다. 최초 PR 이 open 할 땐 packages 설치 과정이 수행되겠지만, 그 이후 부터는 별도의 pr 에서도 캐싱의 이점을 활용할 수 있어 개발 생산성 향상에 기여할 수 있었습니다.
- ❌ 잘못된 캐싱: 중간 과정(다운로드 파일)만 캐싱
- ✅ 올바른 캐싱: 최종 결과물(설치된 패키지) 캐싱
물론 추가적으로 개선할 부분도 존재합니다. 현재 CI 과정은 사이즈가 큰(e.g python2-slim, mysql:5.7, amazon/dynamodb-local) 컨테이너 이미지를 매번 pull 해야 합니다. 이미지가 self hosted runner 에 없다면 Docker hub 에서 이미지 다운 후 압축 해제 및 준비 과정까지 거쳐야 합니다. self hosted runner 는 동일한 서버에서 계속 실행되므로 자주 사용하는 이미지를 한 번만 미리 받아두고, 모든 CI 에서 재사용할 수 있도록 개선하면 추가적인 개선도 가능할 것으로 보입니다.
- Runner 서버에서 1회만 실행 (초기 설정 또는 정기 업데이트 시) docker pull python:2.7-slim docker pull mysql:5.7.34 docker pull amazon/dynamodb-local:latest